import random, time, math
from cocos.actions import *
from gamelib import character, namegen, pathfinding, actions, items, actions,\
        los, party

def start_turn(level):
    '''Given a gamelib.level.Level instance, figure out what the
    NPCs in that level should do.
    '''
    # XXX actually do something here :)
    pass


class NPC(character.Character):
    def __init__(self, *a, **kw):
        if 'memory' in kw:
            self.memory = kw.pop('memory')
        else:
            self.memory = {}

        super(NPC, self).__init__(*a, **kw)

        self.next_waypoint = self.position

    def friend_or_foe(self, other):
        '''Return boolean whether this character is on the same side as other.
        '''
        return isinstance(other, NPC)

    def move_to(self, position, cost):
        ok = super(NPC, self).move_to(position, cost)
        if ok:
            # clear our memory of this cell if we remember
            # someone being in ie
            for name, remembered in self.memory.items():
                if position == remembered:
                    del self.memory[name]
        return ok

    # XXX passing level here (and storing it later) creates a circle
    def run(self, level):
        '''Run this NPC in the given level for a turn.

        This method may do a number of things:
        
            1. return an actions.Action-alike instance to .do()
            2. return None indicating the NPC can do nothing
            3. raise AllDone (same as returning None)
            4. raise ReplaceState
        '''
        return None

    def find_adjacent_pc(self):
        '''Finds the nearest active PC in sight to this NPC.
        '''
        targets = []
        si, sj = self.position
        for i, j in ((-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1),
                (1, 0), (1, 1)):
            char = self.level.get_at_cell(si+i, sj+j)
            if isinstance(char, party.PC):
                targets.append(char)
        return targets

    def find_visible_pcs(self):
        '''Find all currently-visible alive PCs.
        '''
        # figure out if we should notify any comrades if we see someone
        i, j = self.position
        notify_npcs = []
        for npc in self.level.npcs:
            ni, nj = npc.position
            distance = math.sqrt((ni-i)**2 + (nj-j)**2)
            if distance < 10:
                notify_npcs.append(npc)

        targets = []
        # now determine whether we can see anyone

        for c in self.level.party.characters:
            if c.is_dead(): continue
            # LOS check ignoring creatures
            sight = los.LineOfSight(self.level, self.position, c.position, True)
            if not sight: continue

            # update my memory and my comrade's memories
            self.memory[c.name] = c.position
            for npc in notify_npcs:
                # XXX we should give the NPC another run this turn...
                npc.memory[c.name] = c.position

            targets.append((sight, c))
        return targets

    def find_nearest_pc(self):
        '''Finds the nearest PC. The PC may not be in sight -
        we might be working from memory. Precedence will be given to visible
        PCs though.
        '''
        targets = self.find_visible_pcs()

        if not targets:
            if not self.memory:
                return None
            targets = []
            i, j = self.position
            for name in self.memory:
                pc = self.level.party.get(name)
                ni, nj = pc.position
                targets.append((math.sqrt((ni-i)**2 + (nj-j)**2), pc))

            return None

        targets.sort()
        return targets[0][1]

class NPCStateChange(Exception):
    pass

class AllDone(NPCStateChange):
    '''This should be raised if the NPC has nothing to do.
    '''

class ReplaceState(NPCStateChange):
    '''This state replaces the executed state in the current step.
    '''
    def __init__(self, new_state):
        self.new_state = new_state

class State(object):
    # XXX if we save levels these will need to be saved too

    def find_new_target(self, npc):
        '''Find a new target for te NPC.
        '''
        pc = npc.find_nearest_pc()
        if not pc:
            return None
        print 'FOUND NEW TARGET', pc
        # figure potential moves to get me to a character
        return Attacking(pc)

    def validate_current_path(self, npc):
        '''Given the state has a .path attribute check that the NPC
        passed is still able to traverse it. If not, set the path to None.

        Return boolean whether the path is valid.
        '''
        if self.path is None:
            return False
        if not self.path.nodes:
            # path is empty
            self.path = None
            return False

        # check that the next step is still empty
        node = self.path.nodes[0]
        cell = node.location
        contents = npc.level.get_at_cell(*cell)
        if contents:
            self.path = None
            return False

        # XXX mutable maps: check the path is still valid

        return True

class Waiting(State):
    '''The NPC has nothing to do.

    1. is there a visible target to attack?
    2. is there a waypoint here?
    '''
    def run(self, npc):
        print npc, self, npc.actions_remaining
        new = self.find_new_target(npc)
        if new:
            raise ReplaceState(new)

        if npc.memory:
            # pick the closest VALID move cell to the closest PC
            # in my memory
            ni, nj = npc.position
            l = []
            for i, j in npc.memory.values():
                l.append((math.sqrt((ni-i)**2 + (nj-j)**2), (i, j)))
            l.sort()
            destination = l[0][1]
            # find a path to that destination and don't give up if we can't
            # make it the whole way
            path = pathfinding.find_path(npc.level, npc.position,
                destination, close_enough=True)
            if path is None:
                raise ValueError('%r FAILED TO PATH %r -> %r'%(npc, npc.position, destination))
            raise ReplaceState(WalkTo(npc, destination, path))

        # figure movement to my next waypoint
        next = npc.level.ground.get_cell(*npc.position).get('next-waypoint')
        if next:
            next = tuple(map(int, next.split(',')))
            path = pathfinding.find_path(npc.level, npc.position, next)
            if path:
                print 'NEW WAYPOINT', next
                raise ReplaceState(WalkTo(npc, next, path))
            else:
                raise ReplaceState(Waiting())

        # nothing to do
        return None


class NPCMoveAction(object):
    '''Simplified move action that is passed a path to move along.

    The path should be validated before each request of the do() method
    - that is by the State that returns the action for execution.
    '''
    def __init__(self, npc, path):
        self.npc = npc
        self.path = path

    def do(self, callback):
        '''Move to the next step in the path I was handed.
        '''
        # get the next move in the path
        next = self.path.nodes.pop(0)
        print 'CURRENTLY AT', self.npc.position, 'ADVANCE TO', next

        level = self.npc.level

        def done(self=self, next=next, callback=callback):
            self.npc.move_to(next.location, next.nCost)
            if callback is not None: callback()
        done()
        # XXX make this maybe work some day
        #sprite = level.character_layer.get_npc_sprite(self.npc)
        #cell = level.get_cell(*next.location)
        #level.do(MoveTo(cell.center, 1) + CallFunc(done), sprite)

class WalkTo(State):
    '''The NPC will move to some destination.

    At the start of each invocation the NPC will check whether it can
    see a new PC target.
    '''
    def __init__(self, npc, destination, path=None):
        self.destination = destination
        # cache the movement path
        if path is None:
            path = pathfinding.find_path(npc.level, npc.position, destination)
        self.path = path

    def run(self, npc):
        print npc, self, npc.actions_remaining
        if npc.position == self.destination:
            raise ReplaceState(Waiting())

        new = self.find_new_target(npc)
        if new:
            raise ReplaceState(new)

        # not enough actions remaining to move?
        if npc.actions_remaining < 1:
            return None

        if not self.path.nodes:
            raise ReplaceState(Waiting())

        # check that the destination cell is still empty
        if not self.validate_current_path(npc):
            raise ReplaceState(Waiting())

        # move along the path
        return NPCMoveAction(npc, self.path)


class Attacking(State):
    '''The NPC has a target in sight and will attempt to attack it.

    At each invocation the NPC will:

    1. check the target is still alive
    2. try to attack
    3. if it can't it will try to move closer

    If the PC is no longer visible the NPC will move to the last place
    it saw the PC using a standard WalkTo.
    '''
    def __init__(self, pc):
        self.pc = pc            # current target PC
        self.cell = pc.position # last-known location of target PC
        self.path = None

    def run(self, npc):
        print npc, self, npc.actions_remaining
        if self.pc.is_dead():
            raise ReplaceState(Waiting())

        # figure whether we can attack
        # desirability of each attack is based on this weight times
        # the chance of it succeeding
        weights = {
            ('attack', 'bow:bow'): 4,
            ('attack', 'sword:sword'): 2,
            ('attack', 'bash:bash'): 1,
        }
        attack_options = []
        bad_options = []
        map_cell = npc.level.ground.get_cell(*self.cell)
        # go through all visible PCs to figure the best attack
        for x, pc in npc.find_visible_pcs():
            for action in npc.get_action_options():
                key = action.type, action.name
                if key not in weights:
                    continue
                if not action.check(npc.level, npc, map_cell, pc):
                    continue
                w = weights[key] * action.chance
                if w > .25:
                    attack_options.append((w, action, pc))
                else:
                    bad_options.append((w, action, pc))

        # XXX an archer will want to NOT get closer
        # XXX also if we can't move then we might as well take the terrible shot

        if attack_options:
            attack_options.sort()
            attack_options.reverse()
            w, action, pc = attack_options[0]
            npc.listening_to = pc
            pc.push_handlers(npc)
            return action

        # not enough actions remaining to move?
        if npc.actions_remaining < 1:
            return None

        if self.pc.position != self.cell:
            # PC has moved, check we can see them and adjust movement accordingly
            sight = los.LineOfSight(npc.level, npc.position,
                self.pc.position, True)

            # If we can no longer see them then just walk to the last point
            # we saw them - WalkTo will interrupt if another PC is seen along
            # the way
            if not sight:
                # find a path to that destination and don't give up if we can't
                # make it the whole way
                path = pathfinding.find_path(npc.level, npc.position,
                    self.cell, close_enough=True)
                if path is None:
                    raise ReplaceState(Waiting())
                raise ReplaceState(WalkTo(npc, self.cell, path))

            # Can still see the PC so re-target movement
            self.cell = self.pc.position
            self.path = None

        # check current path is still valid
        self.validate_current_path(npc)

        # move closer
        if self.path is None:
            # find an empty spot next to the target
            destination = pathfinding.find_empty_adjacent_cell(npc.level,
                npc.position, self.cell)
            if destination is None:
                # currently nothing this NPC can do
                # XXX strategy: there might be a target open elsewhere
                return None

            self.path = pathfinding.find_path(npc.level, npc.position,
                destination, ignore=(npc, self.pc))

        if self.path is None:
            # can see a target but can't attack or move to them
            if bad_options:
                bad_options.sort()
                bad_options.reverse()
                w, action, pc = bad_options[0]
                npc.listening_to = pc
                pc.push_handlers(npc)
                return action
            return None

        return NPCMoveAction(npc, self.path)

class SimpleNPC(NPC):
    '''Simple NPC behaviour:

    1. Can I see a PC? If so, attempt to attack it (moving closer if necessary).
    2. Do I remember being told about a PC? If so move to its last-known location.
    3. Am I on a waypoint? If so then move to the next point.
    4. Do nothing.
    '''
    state = None

    def run(self, level):
        self.level = level

        # for timing this NPC's turn
        self.start_time = time.time()

        # remember if we push ourself onto a character for events
        self.listening_to = None

        # debugging
        print '\nRUN', self, self.position

        if self.state is None:
            self.state = Waiting()

        # run the first step of the NPC's turn
        self.run_step()

    def run_step(self):
        '''Run a single step of brain activity for this NPC.
        '''
        print 'STEP', self.position, self.actions_remaining
        level = self.level

        if self.listening_to is not None:
            self.listening_to.remove_handlers(self)

        if self.actions_remaining <= 0:
            return self.done()

        while self.actions_remaining:
            try:
                action = self.state.run(self)
                if action is None:
                    break
                # ask the action to invoke run_step to continue the NPC's turn
                # when it's finished doing whatever it needs to (may include
                # animation)
                action.do(self.run_step)
                return
            except AllDone:
                break
            except ReplaceState, r:
                print self, 'IS NOW', r.new_state
                self.state = r.new_state

        # nothing for this NPC to do
        self.done()

    def done(self):
        print 'NPC done in ', time.time()-self.start_time, 'seconds'
        self.level.do(Delay(0.3) + CallFunc(self.level.next_non_player_character))

    def on_character_death(self, character):
        if isinstance(self.state, Attacking):
            self.state = Waiting()

implementations = dict(
    simple = SimpleNPC,
)

# per-NPC-class stats
class_defaults = dict(
    # the weakest of the lot
    kobold = dict(
        klass = 'simple',       # the class above to instantiate for this NPC
        strength = 5,
        dexterity = 5,
        constitution = 3,
        intelligence = 2,
        wisdom = 2,
        charisma = 2,
        max_health = 20,
        max_mana = 0,
        max_actions = 5,
        view_distance_dark = 30,    # haz low-light vision
        name = None,
        equipment = 'crap',
        carried = None,
        skills = dict(weapon_sword=5, bashing=5),
        sprite_name = 'npc-red.png',
    ),

    # a little tougher
    goblin = dict(
        klass = 'simple',       # the class above to instantiate for this NPC
        strength = 6,
        dexterity = 7,
        constitution = 3,
        intelligence = 3,
        wisdom = 2,
        charisma = 2,
        max_health = 30,
        max_mana = 0,
        max_actions = 5,
        view_distance_dark = 30,    # haz low-light vision
        name = None,
        equipment = 'better_crap',
        carried = None,
        skills = dict(weapon_sword=9, bashing=7),
        sprite_name = 'npc-green.png',
    ),
)

loadouts = dict(
    crap = dict(
        right_hand = items.Sword(quality=.5),
        chest = items.HideVest(quality=.75),
    ),
    ranged_crap = dict(
        right_hand = items.Bow(quality=.75),
        chest = items.HideVest(quality=.75),
    ),
    better_crap = dict(
        right_hand = items.Sword(),
        chest = items.HideVest(),
    ),
)

def create(cell):
    '''Create a new NPC using the meta-data from the cell.

    The cell will define an npc-class which gives us the base for the NPC. The
    cell may then also supply additional NPC properties to override the
    defaults.
    '''
    gender = random.choice(['male','female'])
    cclass = cell['npc-class']

    # figure the stats looking in the cell first and then the defaults
    info = {}
    defaults = class_defaults[cclass]
    for k in defaults:
        if 'npc-'+k in cell:
            info[k] = cell['npc-'+k]
        else:
            info[k] = defaults[k]

    # figure the NPC Python class to use
    klass = implementations[info.pop('klass')]

    # if a name wasn't supplied by the XML generate one
    name = info.pop('name')
    if name is None:
        name = namegen.kuruk()

    # starting equipment
    # XXX allow customisation of equipment lists in XML
    info['equipment'] = loadouts[info.pop('equipment')]

    # create the NPC
    return klass(gender, cclass, name, (cell.i, cell.j), **info)

